/* * Copyright (C) 2014 SCVNGR, Inc. d/b/a LevelUp * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License * is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing permissions and limitations under * the License. */ package com.scvngr.levelup.core.ui.view; import android.content.res.Resources; import android.graphics.Bitmap; import android.os.SystemClock; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.test.ActivityInstrumentationTestCase2; import android.test.suitebuilder.annotation.MediumTest; import android.test.suitebuilder.annotation.SmallTest; import android.view.ViewGroup; import android.view.animation.Animation; import android.widget.LinearLayout.LayoutParams; import com.scvngr.levelup.core.test.LatchRunnable; import com.scvngr.levelup.core.test.R; import com.scvngr.levelup.core.test.TestThreadingUtils; import com.scvngr.levelup.core.ui.view.LevelUpCodeView.OnCodeLoadListener; import com.scvngr.levelup.core.util.EnvironmentUtil; import com.scvngr.levelup.core.util.NullUtils; import com.scvngr.levelup.ui.activity.TestFragmentActivity; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; /** * Tests {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView}. */ @SuppressWarnings("javadoc") public final class LevelUpCodeViewTest extends ActivityInstrumentationTestCase2<TestFragmentActivity> { private HashMapCache mCache; private LevelUpCodeView mLevelUpCodeView; private LevelUpCodeLoaderUnderTest mLoader; private MockQrCodeGenerator mQrCodeGenerator; public LevelUpCodeViewTest() { super(TestFragmentActivity.class); } @Override protected void setUp() throws Exception { super.setUp(); mCache = new HashMapCache(); mCache.clear(); mQrCodeGenerator = new MockQrCodeGenerator(); mLoader = new LevelUpCodeLoaderUnderTest(mQrCodeGenerator, mCache); final TestFragmentActivity activity = getActivity(); getInstrumentation().waitForIdleSync(); TestThreadingUtils.runOnMainSync(getInstrumentation(), activity, new Runnable() { @Override public void run() { mLevelUpCodeView = new LevelUpCodeView(activity); final int layoutSize; if (EnvironmentUtil.isSdk11OrGreater()) { layoutSize = LayoutParams.MATCH_PARENT; } else { /* * Pre-Honeycomb devices do not fare well when rapidly allocating large bitmaps * during tests. Choose a small layout size that is large enough to validate * pixel scaling. */ layoutSize = MockQrCodeGenerator.TEST_IMAGE_SIZE * 3; } ((ViewGroup) activity.findViewById(R.id.levelup_activity_content)).addView( mLevelUpCodeView, new LayoutParams(layoutSize, layoutSize)); } }); } @Override protected void tearDown() throws Exception { /* * Manage Memory on Android 2.3.3 and Lower * https://developer.android.com/training/displaying-bitmaps/manage-memory.html#recycle */ if (!EnvironmentUtil.isSdk11OrGreater()) { TestThreadingUtils.runOnMainSync(getInstrumentation(), getActivity(), new Runnable() { @Override public void run() { mLevelUpCodeView.destroyDrawingCache(); } }); } super.tearDown(); } /** * Tests that the test view is set up properly. */ @SmallTest public void testSetUpCorrectly() { assertNotNull(mLevelUpCodeView); assertTrue(mLevelUpCodeView.isShown()); } /** * Tests {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView#setLevelUpCode(String, com.scvngr.levelup.core.ui.view.LevelUpCodeLoader)} with a cache hit. * * @throws InterruptedException */ @MediumTest public void testShowCode_cacheHit() throws InterruptedException { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); final LatchingOnCodeLoadListener onCodeLoadListener = new LatchingOnCodeLoadListener(); mLevelUpCodeView.setOnCodeLoadListener(onCodeLoadListener); mCache.putCode(key1, mQrCodeGenerator.mTestImage1); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); /* * The callback should only be called once with loading=false, so progress bars and such can * be handled properly. */ assertOnCodeLoaded(onCodeLoadListener, false, 1); assertOnCodeLoaded(onCodeLoadListener, true, 0); MockQrCodeGenerator.isBitmapForCode(mLevelUpCodeView.mCurrentCode, MockQrCodeGenerator.TEST_CONTENT1); } /** * Tests {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView#setLevelUpCode(String, com.scvngr.levelup.core.ui.view.LevelUpCodeLoader)} with a cache miss. * * @throws InterruptedException */ @MediumTest public void testShowCode_cacheMiss() throws InterruptedException { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); final LatchingOnCodeLoadListener onCodeLoadListener = new LatchingOnCodeLoadListener(); mLevelUpCodeView.setOnCodeLoadListener(onCodeLoadListener); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); assertOnCodeLoaded(onCodeLoadListener, false, 0); assertOnCodeLoaded(onCodeLoadListener, true, 1); final boolean[] result = new boolean[1]; getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { // Now simulate that the code loaded in the background. result[0] = mLoader.dispatchOnImageLoaded(key1, mQrCodeGenerator.mTestImage1); } }); assertTrue("dispatchOnImageLoaded's callback was not called", result[0]); assertOnCodeLoaded(onCodeLoadListener, false, 1); assertOnCodeLoaded(onCodeLoadListener, true, 1); } /** * Tests {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView#setLevelUpCode(String, com.scvngr.levelup.core.ui.view.LevelUpCodeLoader)} being called twice, * to change the code. * * @throws InterruptedException */ @MediumTest public void testShowCode_changing() { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); mCache.putCode(key1, mQrCodeGenerator.mTestImage1); final LatchingOnCodeLoadListener onCodeLoadListener = new LatchingOnCodeLoadListener(); mLevelUpCodeView.setOnCodeLoadListener(onCodeLoadListener); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); MockQrCodeGenerator.isBitmapForCode(mLevelUpCodeView.mCurrentCode, MockQrCodeGenerator.TEST_CONTENT1); assertTestColorPixelEquals(getLevelUpCodeViewDrawingCache(), MockQrCodeGenerator.TEST_CONTENT1_COLOR); assertOnCodeLoaded(onCodeLoadListener, false, 1); assertOnCodeLoaded(onCodeLoadListener, true, 0); // Now change the code. final String key2 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT2); mCache.putCode(key2, mQrCodeGenerator.mTestImage2); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT2, mLoader); } }); MockQrCodeGenerator.isBitmapForCode(mLevelUpCodeView.mCurrentCode, MockQrCodeGenerator.TEST_CONTENT2); assertTestColorPixelEquals(getLevelUpCodeViewDrawingCache(), MockQrCodeGenerator.TEST_CONTENT2_COLOR); assertOnCodeLoaded(onCodeLoadListener, false, 2); assertOnCodeLoaded(onCodeLoadListener, true, 0); } /** * Tests {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView#setLevelUpCode(String, com.scvngr.levelup.core.ui.view.LevelUpCodeLoader)} being called twice, * with the same code. * * @throws InterruptedException */ @MediumTest public void testShowCode_unchanged() { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); mCache.putCode(key1, mQrCodeGenerator.mTestImage1); final LatchingOnCodeLoadListener onCodeLoadListener = new LatchingOnCodeLoadListener(); mLevelUpCodeView.setOnCodeLoadListener(onCodeLoadListener); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); MockQrCodeGenerator.isBitmapForCode(mLevelUpCodeView.mCurrentCode, MockQrCodeGenerator.TEST_CONTENT1); assertTestColorPixelEquals(getLevelUpCodeViewDrawingCache(), MockQrCodeGenerator.TEST_CONTENT1_COLOR); assertOnCodeLoaded(onCodeLoadListener, false, 1); assertOnCodeLoaded(onCodeLoadListener, true, 0); // Set the same code again. getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); assertTestColorPixelEquals(getLevelUpCodeViewDrawingCache(), MockQrCodeGenerator.TEST_CONTENT1_COLOR); assertOnCodeLoaded(onCodeLoadListener, false, 2); assertOnCodeLoaded(onCodeLoadListener, true, 0); } /** * Tests that {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView}'s colorizing of the QR code matches the expected color * pattern. */ @MediumTest public void testShowCode_colorizing() { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); mCache.putCode(key1, mQrCodeGenerator.mTestImage1); final LatchingOnCodeLoadListener onCodeLoadListener = new LatchingOnCodeLoadListener(); mLevelUpCodeView.setOnCodeLoadListener(onCodeLoadListener); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setFadeColors(false); mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); MockQrCodeGenerator.isBitmapForCode(mLevelUpCodeView.mCurrentCode, MockQrCodeGenerator.TEST_CONTENT1); final Bitmap drawingCache = getLevelUpCodeViewDrawingCache(); final Resources resources = getActivity().getResources(); final int orange = resources.getColor(R.color.levelup_logo_orange); final int blue = resources.getColor(R.color.levelup_logo_blue); final int green = resources.getColor(R.color.levelup_logo_green); assertScaledPixelIsColor(drawingCache, MockQrCodeGenerator.TARGET_RIGHT, MockQrCodeGenerator.TARGET_BOTTOM, MockQrCodeGenerator.TEST_IMAGE_SIZE, blue); assertScaledPixelIsColor(drawingCache, MockQrCodeGenerator.TARGET_RIGHT, MockQrCodeGenerator.TARGET_TOP, MockQrCodeGenerator.TEST_IMAGE_SIZE, orange); assertScaledPixelIsColor(drawingCache, MockQrCodeGenerator.TARGET_LEFT, MockQrCodeGenerator.TARGET_BOTTOM, MockQrCodeGenerator.TEST_IMAGE_SIZE, green); } /** * Tests that {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView}'s colorizing of the QR code is disabled when * {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView#setColorize(boolean)} is false. */ @MediumTest public void testShowCode_notColorizing() { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); mCache.putCode(key1, mQrCodeGenerator.mTestImage1); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { // Disable colorization mLevelUpCodeView.setColorize(false); mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); final Bitmap drawingCache = getLevelUpCodeViewDrawingCache(); final Resources resources = getActivity().getResources(); final int black = resources.getColor(android.R.color.black); assertScaledPixelIsColor(drawingCache, MockQrCodeGenerator.TARGET_RIGHT, MockQrCodeGenerator.TARGET_BOTTOM, MockQrCodeGenerator.TEST_IMAGE_SIZE, black); assertScaledPixelIsColor(drawingCache, MockQrCodeGenerator.TARGET_RIGHT, MockQrCodeGenerator.TARGET_TOP, MockQrCodeGenerator.TEST_IMAGE_SIZE, black); assertScaledPixelIsColor(drawingCache, MockQrCodeGenerator.TARGET_LEFT, MockQrCodeGenerator.TARGET_BOTTOM, MockQrCodeGenerator.TEST_IMAGE_SIZE, black); } /** * Tests that {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView}'s color fading is animating the color alpha. */ @MediumTest public void testShowCode_fading() { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); mCache.putCode(key1, mQrCodeGenerator.mTestImage1); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); final long timeoutMillis = LevelUpCodeView.ANIM_FADE_START_DELAY_MILLIS + LevelUpCodeView.ANIM_FADE_DURATION_MILLIS + TimeUnit.SECONDS.toMillis(4); final LatchRunnable latchRunnable = new LatchRunnable() { @Override public void run() { if (LevelUpCodeView.ANIM_FADE_COLOR_ALPHA_END == mLevelUpCodeView.mColorAlpha) { countDown(); } } }; assertTrue(TestThreadingUtils.waitForAction(getInstrumentation(), getActivity(), latchRunnable, timeoutMillis, true)); } /** * Tests that {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView}'s color fading is disabled when calling * {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView#setFadeColors(boolean)}. */ @MediumTest public void testShowCode_notFading() { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); mCache.putCode(key1, mQrCodeGenerator.mTestImage1); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { mLevelUpCodeView.setFadeColors(false); mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); final Animation animation = mLevelUpCodeView.getAnimation(); assertNull(animation); assertEquals(LevelUpCodeView.ANIM_FADE_COLOR_ALPHA_START, mLevelUpCodeView.mColorAlpha); } /** * Tests that {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView} isn't doing a fade animation when the colorization is * disabled. */ @MediumTest public void testShowCode_notFadingBecauseNotColorized() { final String key1 = mLoader.getKey(MockQrCodeGenerator.TEST_CONTENT1); mCache.putCode(key1, mQrCodeGenerator.mTestImage1); getInstrumentation().runOnMainSync(new Runnable() { @Override public void run() { // Disable colorization mLevelUpCodeView.setColorize(false); mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); } }); getInstrumentation().waitForIdleSync(); final Animation animation = mLevelUpCodeView.getAnimation(); assertNull(animation); assertEquals(LevelUpCodeView.ANIM_FADE_COLOR_ALPHA_START, mLevelUpCodeView.mColorAlpha); } /** * Tests that setLevelUpCode enforces being called from the correct thread. */ @MediumTest public void testShowCode_wrongThread() { try { mLevelUpCodeView.setLevelUpCode(MockQrCodeGenerator.TEST_CONTENT1, mLoader); fail("Expected exception not thrown."); } catch (final AssertionError e) { // Expected exception. } } /** * Tests that the {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView} is square and not 0px wide. */ @SmallTest public void testSizing() { assertTrue(0 < mLevelUpCodeView.getWidth()); // The view should be square. assertEquals(mLevelUpCodeView.getWidth(), mLevelUpCodeView.getHeight()); } /** * Asserts the number of calls to {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView.OnCodeLoadListener#onCodeLoad} that * were performed with the specified loading state. * * @param listener The {@link com.scvngr.levelup.core.ui.view.LevelUpCodeViewTest.LatchingOnCodeLoadListener}. * @param expectedIsCodeLoading The expected isCodeLoading state that should have been delivered * the listener. * @param expectedCount The expected number of times the callback has been invoked with * the specified loading state. */ private void assertOnCodeLoaded(@NonNull final LatchingOnCodeLoadListener listener, final boolean expectedIsCodeLoading, final int expectedCount) { final int expectedLatchCount = expectedCount == 0 ? 1 : 0; final LatchRunnable latchRunnable = new LatchRunnable() { @Override public void run() { if (expectedIsCodeLoading) { if (expectedLatchCount == listener.isCodeLoadingTrueLatch.getCount()) { countDown(); } } else { if (expectedLatchCount == listener.isCodeLoadingFalseLatch.getCount()) { countDown(); } } } }; assertTrue(TestThreadingUtils.waitForAction(getInstrumentation(), getActivity(), latchRunnable, false)); if (expectedIsCodeLoading) { assertEquals(expectedCount, listener.isCodeLoadingTrueCount); } else { assertEquals(expectedCount, listener.isCodeLoadingFalseCount); } } /** * Asserts that the given pixel in the provided bitmap is a given color, given the coordinates * of the pre-scaled-up pixels. * * @param bitmap the full-size bitmap. * @param x pre-scaled x. * @param y pre-scaled y. * @param miniSize size of the pre-scaled bitmap. * @param expectedColor the expected color. */ private void assertScaledPixelIsColor(@NonNull final Bitmap bitmap, final int x, final int y, final int miniSize, final int expectedColor) { final int largeSize = bitmap.getWidth(); final float scaleFactor = (float) largeSize / miniSize; assertEquals(expectedColor, bitmap.getPixel((int) (scaleFactor * x + scaleFactor / 2), (int) (scaleFactor * y + scaleFactor / 2))); } /** * Asserts that the pixel used to test bitmap equality is a given color. * * @param bitmap the bitmap to check. * @param expectedColor the expected color. */ private void assertTestColorPixelEquals(@NonNull final Bitmap bitmap, final int expectedColor) { assertScaledPixelIsColor(bitmap, MockQrCodeGenerator.TEST_COLOR_PIXEL, MockQrCodeGenerator.TEST_COLOR_PIXEL, MockQrCodeGenerator.TEST_IMAGE_SIZE, expectedColor); } /** * Get the drawing cache from {@link #mLevelUpCodeView}. * * @return the drawing cache. */ @NonNull private Bitmap getLevelUpCodeViewDrawingCache() { if (EnvironmentUtil.isSdk11OrGreater()) { return NullUtils.nonNullContract(getLevelUpCodeViewDrawingCacheHelper()); } /* * Attempt to build the drawing cache multiple times on older devices. This can take a while * due to the unpredictable nature of native memory management. */ final long endTime = SystemClock.elapsedRealtime() + TimeUnit.SECONDS.toMillis(20); while (true) { getInstrumentation().waitForIdleSync(); final Bitmap bitmap = getLevelUpCodeViewDrawingCacheHelper(); if (null != bitmap) { return NullUtils.nonNullContract(bitmap); } assertTrue("Timed out getting the code view drawing cache.", SystemClock.elapsedRealtime() < endTime); SystemClock.sleep(500); } } /** * Helper to obtain the drawing cache bitmap. This may temporarily return null on older devices. * Clients should use {@link #getLevelUpCodeViewDrawingCache} instead of this method. * * @return the drawing cache bitmap or null if the drawing cache could not be built. */ @Nullable private Bitmap getLevelUpCodeViewDrawingCacheHelper() { final AtomicReference<Bitmap> drawingCacheReference = new AtomicReference<Bitmap>(); TestThreadingUtils.runOnMainSync(getInstrumentation(), getActivity(), new Runnable() { @Override public void run() { mLevelUpCodeView.destroyDrawingCache(); mLevelUpCodeView.buildDrawingCache(); drawingCacheReference.set(mLevelUpCodeView.getDrawingCache()); } }); return drawingCacheReference.get(); } /** * An {@link com.scvngr.levelup.core.ui.view.LevelUpCodeView.OnCodeLoadListener} that tallies calls to its callback. */ private static class LatchingOnCodeLoadListener implements OnCodeLoadListener { public int isCodeLoadingFalseCount = 0; public final CountDownLatch isCodeLoadingFalseLatch = new CountDownLatch(1); public int isCodeLoadingTrueCount = 0; public final CountDownLatch isCodeLoadingTrueLatch = new CountDownLatch(1); @Override public void onCodeLoad(final boolean isCodeLoading) { if (isCodeLoading) { isCodeLoadingTrueLatch.countDown(); isCodeLoadingTrueCount++; } else { isCodeLoadingFalseLatch.countDown(); isCodeLoadingFalseCount++; } } } }